<?php
namespace phiction;
use \phiction\exceptions\{bad_schema, unimplemented};
use \phiction\array_utils as arr;

class schema_loader
{
    public $schemas = [];
    private $loaded_module = [];
    private $identity_field = [];

    function load(string $module)
    {
        $this->load_module($module);
        $this->validate();
    }

    // load schemas from all modules
    function scan()
    {
        foreach (path::module_iter() as $module)
            if (path::module_has_storage($module))
                $this->load($module);
    }

    function identity_field(string $schema_name): string
    {
        return $this->identity_field[$schema_name];
    }

    function contains(string $schema_name): bool
    {
        return array_key_exists($schema_name, $this->schemas);
    }

    function schema(string $name): array
    {
        if (!$this->contains($name)) throw new \InvalidArgumentException("schema not found: $name");
        return $this->schemas[$name];
    }

    function schema_field_type(string $schema_name, string $field): array
    {
        $schema = $this->schema($schema_name);
        if (!array_key_exists($field, $schema)) throw new \InvalidArgumentException("field not found in schema \"$schema_name\": $field");
        return $schema[$field];
    }

    static function sql_name(string $name): string
    {
        return '"' . $name . '"';
    }

    private function load_module(string $module)
    {
        if (array_key_exists($module, $this->loaded_module)) return;
        $this->loaded_module[$module] = true;

        $storage = path::module_storage($module);
        $schemas = self::desugar($storage::schemas());

        foreach ($schemas as $name => $schema) {
            if (array_key_exists($name, $this->schemas)) throw new bad_schema("duplicate schema in $module: $name");
            $this->schemas[$name] = $schema;

            foreach ($schema as $field => $type) {
                if ($type['type'] === 'identity')
                    $this->identity_field[$name] = $field;
                if (array_key_exists('from', $type) && !is_null($type['from']))
                    $this->load_module($type['from']);
            }
        }
    }

    private function validate()
    {
        $this->validate_single_identity();
        $this->validate_reference();
    }

    private function validate_single_identity()
    {
        foreach ($this->schemas as $name => $schema) {
            $num_identity = 0;
            foreach ($schema as $type)
                if ($type['type'] === 'identity')
                    $num_identity++;
            if ($num_identity === 0) throw new bad_schema("schema must have an identity field: $name");
            if ($num_identity > 1) throw new bad_schema("schema can only have 1 identity field: $name");
        }
    }

    private function validate_reference()
    {
        foreach ($this->schemas as $name => $schema)
            foreach ($schema as $type)
                switch ($type['type']) {
                    case 'table':
                        $table = $type['name'];
                        if (!array_key_exists($table, $this->schemas))
                            throw new bad_schema("schema \"$name\" references non existent table: $table");
                        break;
                    case 'sequence':
                        throw new unimplemented("validate sequence");
                        break;
                    case 'enum':
                        // nothing to be validated here
                        break;
                    default: break;
                }
    }

    private static function desugar($schemas)
    {
        $result = [];
        foreach ($schemas as $name => $schema)
            $result[$name] = self::desugar_schema($schema);
        return $result;
    }

    private static function desugar_schema($schema)
    {
        $result = [];
        foreach ($schema as $field => $type)
            $result[$field] = self::desugar_type($type);
        return $result;
    }

    /*
        example:
            'string'      -> ['type' => 'string'  , 'nullable' => false, 'max-length' => null]
            'string/256'  -> ['type' => 'string'  , 'nullable' => false, 'max-length' => 256 ]
            '@user'       -> ['type' => 'table'   , 'nullable' => false, 'name' => 'user', 'from' =>  null ]
            '?@user/auth' -> ['type' => 'table'   , 'nullable' => true , 'name' => 'user', 'from' => 'auth']
            '?#seq'       -> ['type' => 'sequence', 'nullable' => true , 'name' => 'seq' , 'from' =>  null ]
            '#seq/auth'   -> ['type' => 'sequence', 'nullable' => false, 'name' => 'seq' , 'from' => 'auth']
            '*role/hello' -> ['type' => 'enum'    , 'nullable' => false, 'name' => 'role', 'enum' => ['hello' => true]]
            '*role/hello:world:foo'
                          -> ['type' => 'enum'    , 'nullable' => false, 'name' => 'role', 'enum' => ['hello' => true, 'world' => true, 'foo' => true]]

        fields formalization:
            all have:
                string type
                bool   nullable
            extension typed (except enum):
                string name
                string from
            extension typed (enum):
                string name
                array<string> enum
            non-extension typed:
                int|null max-length
    */
    static function desugar_type(string $type): array
    {
        if (strlen($type) === 0) throw new \InvalidArgumentException("type must contain at least 1 byte");

        # nullable
        $nullable = false;
        if (substr($type, 0, 1) === '?') {
            $nullable = true;
            $type = substr($type, 1);
        }

        # extension typed
        static $extensions = ['@' => 'table', '#' => 'sequence', '*' => 'enum'];
        $ext = substr($type, 0, 1);
        if (array_key_exists($ext, $extensions)) {
            $type = substr($type, 1);
            $ext_name = $extensions[$ext];
            $shards = explode('/', $type);
            if (count($shards) > 2) throw new \InvalidArgumentException("$ext$ext_name type can have at most two parts: $type");
            $name = self::sanitize_name($shards[0]);
            if ($ext_name === 'enum') {
                $enum = arr::get_or_throw($shards, 1,
                    new \InvalidArgumentException("*enum type must have the second parts: $type"));
                if ($enum === '')
                    throw new \InvalidArgumentException("*enum type must have non-empty second parts: $type");
                $enum = explode(':', $enum);
                $enum = array_fill_keys($enum, true);

                return [
                    'nullable' => $nullable,
                    'type' => $ext_name,
                    'name' => $name,
                    'enum' => $enum,
                ];
            } else {
                $from = arr::get_or_null($shards, 1);
                return [
                    'nullable' => $nullable,
                    'type' => $ext_name,
                    'name' => $name,
                    'from' => $from,
                ];
            }
        }

        # non-extension typed
        $shards = explode('/', $type);
        if (count($shards) > 2) throw new \InvalidArgumentException("non-extension type can only have two parts: $type");
        $type = self::sanitize_name($shards[0]);
        $max_len = count($shards) >= 2 ? (int)$shards[1] : null;
        if (!is_null($max_len) && $max_len < 1) throw new \InvalidArgumentException("non-extension type max-length must be at least 1: $shards[1]");
        return [
            'nullable' => $nullable,
            'type' => $type,
            'max-length' => $max_len,
        ];
    }

    static function sanitize_name(string $name): string
    {
        if (strlen($name) === 0) throw new \InvalidArgumentException("name must contain at least 1 byte");
        if (preg_match('#[^-a-z0-9]#', $name)) throw new \InvalidArgumentException("invalid name: $name");
        if (preg_match('#^[0-9]#', $name)) throw new \InvalidArgumentException("name cannot begin with numbers: $name");
        return $name;
    }
}

